在圣诞活动中遇到一个需求,要实现将物体可以装饰在圣诞树中
圣诞树
大概需求如上,从下方拖拽道具,可以放到树上,下面的道具以及树都是图片,如果拖拽区域不在树上,自然不能拖放,最终的实现效果如下:(忽略这个toast,可以去掉)

视频中可以看到几个功能点:

  1. 点击下方道具库可以添加到树上
  2. 已经在树上的道具可以随意拖拽
  3. 拖拽超出三角形的范围之后为无效,会自动回到上一次拖拽位置

这个需求中涉及的难点主要是,如何才能对两张图做一个准确的碰撞检测?刚开始实现的想法,是做一个三角形检测函数,即给定三个点的坐标,然后判断当前装扮物的位置是否在三个点构成的三角形中,但是发现存在一些问题:圣诞树的边缘并不规则,如果仅仅是一个三角形计算,会导致周围真空区域也可以拖拽到

可以看到,在这种情况之下,不规则的边缘并不能很好的实现检测,所以这里想到了之前看过的一个技术文章
https://juejin.cn/post/6844903846221725710《使用 getImageData 实现碰撞检测》
这个文章中大概也是介绍了一下,基于canvas的getImageData来实现碰撞检测

主要实现思路

参考上面的这篇文章,圣诞树主要思路是,在canvas画布拖拽的时候,实时去检测到当前拖住点的rgba的值,判断透明度是不是存在,如果存在那么必定透明度为1,否则为0。

既然是基于canvas的getImageData来实现的,那么就需要先用canvas渲染一个圣诞树图片进去,然后,在手指触碰canvas节点时候,调用getImageData,传入x,y坐标,获取到rgba的值就可以实现。

CanvasRenderingContext2D:getImageData() 方法

看一下MDN官网对于getImageData方法的描述

Canvas 2D API 的 CanvasRenderingContext2D.getImageData() 返回一个 ImageData 对象,用于描述 canvas 指定区域的隐含像素数据。
这个方法不受画布的变换矩阵影响。如果指定的矩形超出画布的边界,返回的 ImageData 对象中超出画布边界的像素将是透明黑色

使用方法

1
2
getImageData(sx, sy, sw, sh)
getImageData(sx, sy, sw, sh, settings)

getImageData

大概意思就是,传入对应的x,y坐标,就会返回一个imageData对象。
ImageData 对象的主要属性包括:

  • width:表示图像数据的宽度(以像素为单位)。
  • height:表示图像数据的高度(以像素为单位)。
  • data:一个 Uint8ClampedArray 类型的数组,包含了图像数据的像素信息。每个像素由四个值组成,分别表示红色(R)、绿色(G)、蓝色(B)和透明度(A),每个值的范围是 0 到 255。

实现代码

首先在dom中绘制一个canvas元素,然后将圣诞树图片渲染好放进去

当点击道具库中的装扮时候,我这里做法粗暴一点,直接获取到这个canvas dom元素的位于屏幕的坐标,计算出来中心点,直接将道具设置为中心就可以了。

拖拽部分实现,首先给元素绑定对应的拖拽事件,touchstart,touchmove以及touchend
直接贴核心代码
handleTouchstart:这是记录了起始拖拽位置,常规代码,额外记录了cacheX和cacheY,缓存了当前位置,如果拖拽超出圣诞树,就用这个位置设置还原。

handleTouchmove:拖拽装扮的过程中,这里需要做一个坐标的计算,因为canvas 这个dom距离顶部是有距离的,我们在页面中移动手指坐标时候,需要将顶部的距离和左侧的距离计算进去(这里调用像素检测函数,是在测试时方便判断,判断是否在圣诞树当中的)

handleTouchEnd:调用像素检测函数getPixel,判断是否在圣诞树上,不在的话还原上一次的值,否则更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 拖拽道具逻辑
let diffX = 0;
let diffY = 0;
let clientX = 0;
let clientY = 0;
const handleTouchstart = (event: TouchEvent, item) => {
diffX =
event.touches[0].clientX -
(event.target!.offsetLeft - event.target!.clientWidth / 2);
diffY =
event.touches[0].clientY -
(event.target!.offsetTop - event.target!.clientHeight / 2);

clientX = event.touches[0].clientX - event.target!.clientWidth / 2;
clientY = event.touches[0].clientY - event.target!.clientHeight / 2;

// 记录下来原始的坐标信息,如果touchend时候超出范围,重新设置为起始位置
// item.cacheX = clientX - diffX;
// item.cacheY = clientY - diffY;
item.cacheX = event.target!.style.left;
item.cacheY = event.target!.style.top;
};

const handleTouchmove = (event: TouchEvent, item) => {
event.preventDefault();
const canvasTop = treeCanvasRef.value!.getBoundingClientRect().top;
const canvasLeft = treeCanvasRef.value!.getBoundingClientRect().left;
const left = event.touches[0].clientX - diffX;
const top = event.touches[0].clientY - diffY;
item.pos_x = left;
item.pos_y = top;
clientX = event.touches[0].clientX - event.target!.clientWidth / 2;
clientY = event.touches[0].clientY - event.target!.clientHeight / 2;
// 测试使用
getPixel(clientX - canvasLeft, clientY - canvasTop).then(res => {
console.log(res, item, "拖拽范围判断");
});
};

const handleTouchEnd = item => {
const canvasTop = treeCanvasRef.value!.getBoundingClientRect().top;
const canvasLeft = treeCanvasRef.value!.getBoundingClientRect().left;
getPixel(clientX - canvasLeft, clientY - canvasTop).then(async res => {
if (!res) {
// 不可以拖拽的区域,重新设置为起始位置
console.log("不可以拖拽的区域,重新设置为起始位置");
item.pos_x = item.cacheX;
item.pos_y = item.cacheY;
return;
}
// const toast = Toast.loading({
// forbidClick: true
// });
const params: IChristmasDecorateTreeRequest = {
props_pos: {
pos_id: item.pos_id,
props_type: item.props_type,
pos_x: item.pos_x,
pos_y: item.pos_y
// pos_x: clientX - canvasLeft,
// pos_y: clientY - canvasTop
},
tree_id: String(storeTreeData.userTreeConfig.tree_id),
user_id: userStore.userInfo.userId
};
try {
const res = await postChristmasEditTree(params);
if (res.code !== 0) {
Toast(res.msg);
}
console.log(params, "拖拽结束");
} catch (error) {
Toast(String(error));
}
// finally {
// toast.clear();
// }
});
};

像素检测的核心代码,判断data[3]/255的值是否为1,是的话就是有像素,在圣诞树上,否则的话就是在透明区域,不在圣诞树当中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  const getPixel = (x: number, y: number) => {
return new Promise(resolve => {
// 减去距离顶部的距离
const pixel = ctx.getImageData(x, y, 1, 1);
const data = pixel.data;
const rgba = `rgba(${data[0]}, ${data[1]}, ${data[2]}, ${data[3] / 255})`;
rgb.value = rgba;

if (data[3] / 255 === 1) {
resolve(true);
} else {
resolve(false);
}
});
};

上面的代码路径在 apps/h5-client/livevoice-business/src/views/2024christmas/hooks/tree/use-tree.ts 中